在 UIKit 時代,將資料存在裝置上,最常使用的物件就是 UserDefaults,但 UserDefaults 並沒有辦法和 SwiftUI 的 @State 等 property wrapper 搭配良好。雖然這個 Demo App 是用 SwiftUI 寫的,但我們仍然可以用 UIViewControllerRepresentable 來進行 UIKit 與 SwiftUI 元件的溝通。
先寫一個每按一下,就 tap count 數就 + 1 的 VC。auto layout 使用 SnapKit,這樣就可以用純程式碼來寫文章。
import SnapKit
import UIKit
class TapCounterViewController: UIViewController {
private var count: Int = 0 {
didSet {
countLabel.text = "已經點擊了 \(count) 下"
UserDefaults.standard.set(count, forKey: "tapCount")
}
}
private lazy var countLabel: UILabel = .init()
private lazy var addButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("按下去就 + 1", for: .normal)
button.addTarget(self, action: #selector(countButtonDidTap), for: .touchUpInside)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
count = UserDefaults.standard.integer(forKey: "tapCount")
}
private func setupUI() {
countLabel.textAlignment = .center
view.addSubview(countLabel)
countLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalTo(200)
make.height.equalTo(44)
}
view.addSubview(addButton)
addButton.snp.makeConstraints { make in
make.width.height.centerX.equalTo(countLabel)
make.top.equalTo(countLabel.snp.bottom).offset(10)
}
}
@objc
private func countButtonDidTap() {
count += 1
print("current tap count: \(count)")
}
}
然後,再開一個 SwiftUI 的 View,把這個 VC 裝進去
import SwiftUI
import UIKit
/// 裝載 UIKit VC 的物件
struct TapCounterRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> TapCounterViewController {
TapCounterViewController()
}
func updateUIViewController(_ uiViewController: TapCounterViewController, context: Context) {
}
typealias UIViewControllerType = TapCounterViewController
}
struct TapCounterView: View {
var body: some View {
/// 一個包著 SwiftUI 皮的 UIKit VC
TapCounterRepresentable()
}
}
struct TapCounterView_Previews: PreviewProvider {
static var previews: some View {
TapCounterView()
}
}
因為是 SwiftUI View 包住的 VC,當然可以用 Preview 來看狀況。這個 feature 在今年的 Xcode 15 應該已經是標配了,不過筆者現在還在 Xcode 14.2 的版本,所以沒有 #preview 的方法來看 UIKit
實際將模擬器 run 起來,並關掉後再打開,你可以看到 tap count 有確實的紀錄起來。
可以的,我們就來測試 UserDefaults
step1: 開一個新檔案,並命名為 UserDefaultsProtocol,在裡面新增一個 UserDefaultsProtocol
/// UserDefaultsProtocol.swift
import Foundation
protocol UserDefaultsProtocol {}
step2: 在 VC 宣告 userDefaults 時,指定型別為 UserDefaultsProtocol 但實作是 UserDefaults.standard,然後把 VC 裡面的 UserDefaults.standard 換成 userDefaults
/// TapCounterViewController.swift
var userDefaults: UserDefaultsProtocol = UserDefaults.standard
然後,你會看到 Xcode 跳出 error,因為 UserDefaultsProtocol 的型別和 UserDefaults 不合
step3: 將 UserDefaults extension UserDefaultsProtocol
extension UserDefaults: UserDefaultsProtocol {}
然後,你會看到其他錯誤,因為 UserDefaultsProtocol 在這個專案沒有兩個被 VC 使用的 func/property
step4: 擴充 UserDefaultsProtocol
從 Xcode 的文件裡面,可以找到 UserDefaults 裡面 func 的宣告,雖然 Objective-C 有點難讀,但如果真的要找,也是可以在 Apple 的官方文件中找到 integer 的宣告。並把這兩個 func 補上。
https://developer.apple.com/documentation/foundation/userdefaults/1413614-set
https://developer.apple.com/documentation/foundation/userdefaults/1407405-integer
/// UserDefaultsProtocol.swift
protocol UserDefaultsProtocol {
func set(_ value: Int, forKey defaultName: String)
func integer(forKey defaultName: String) -> Int
}
step5: 開始測試 UserDefaultsProtocol ,並在 Test target 加上 Fake Object 物件
// UserDefaultsTests.swift
import XCTest
@testable import TwStockTools
class FakeUserDefaults: UserDefaultsProtocol {
var integers: [String: Int] = [:]
func set(_ value: Int, forKey defaultName: String) {
integers[defaultName] = value
}
func integer(forKey defaultName: String) -> Int {
integers[defaultName] ?? 0
}
}
接下來,下一篇,我們要用這個 FakeUserDefaults 進行在 VC 中進行測試